import type { Actor } from "./hydrant.ts"; import type { StarEntry } from "./hydrant.ts"; const css = ` :root { font-family: Arial, Helvetica, sans-serif; color: #111; background: #fff; } * { box-sizing: border-box; } body { max-width: 998px; margin: 0 auto; padding: 0 16px 64px; font-size: 16px; line-height: 1.5; } a { color: #2c73af; text-underline-offset: 3px; } a:hover { color: #195789; } header { display: flex; align-items: center; justify-content: space-between; gap: 20px; min-height: 56px; } header nav { display: flex; align-items: center; justify-content: flex-end; gap: 20px; min-width: 0; } header nav a { overflow-wrap: anywhere; } .nav-signin { padding: 5px 12px; border: 1px solid #aaa; border-radius: 4px; text-decoration: none; white-space: nowrap; } main { padding-top: 28px; } h1 { margin: 0 0 16px; font-size: 32px; line-height: 1.25; overflow-wrap: anywhere; } .intro { margin: 0 0 18px; font-size: 14px; } hr { border: 0; border-top: 1px solid #111; margin: 0 0 48px; } .heading-actor { display: inline-flex; align-items: center; gap: 12px; } .controls { display: flex; flex-wrap: wrap; align-items: end; justify-content: space-between; gap: 16px 32px; margin: -16px 0 32px; } .controls form { margin: 0; } .controls .browse { flex: 1 1 300px; max-width: 480px; } .browse label { margin: 0 0 8px; } .field-row { display: flex; align-items: stretch; gap: 8px; } .field-row input { flex: 1; min-width: 0; width: auto; } .field-row button { flex: none; } #share { max-width: 560px; } #share label[for] { margin-bottom: 0; } .star { margin: 0 0 28px; padding: 16px; border: 1px solid #ddd; border-radius: 8px; box-shadow: 0 3px 15px #0003; } .star-content { border-left: 5px solid #34383a; padding-left: 10px; } .star-link { font-size: 20px; font-weight: 700; overflow-wrap: anywhere; } .domain { display: block; overflow-wrap: anywhere; } .comment { margin: 8px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; } .star-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 20px; margin-top: 11px; } .author { display: inline-flex; align-items: center; gap: 6px; } .avatar { width: 24px; height: 24px; border-radius: 50%; object-fit: cover; } .avatar.large { width: 40px; height: 40px; } form { margin: 24px 0; } label { display: block; margin: 12px 0; } input, textarea { display: block; width: 100%; padding: 9px 10px; font: inherit; border: 1px solid #aaa; border-radius: 4px; } textarea { min-height: 180px; resize: vertical; } button { padding: 7px 14px; font: inherit; color: #111; background: #f5f5f5; border: 1px solid #aaa; border-radius: 4px; cursor: pointer; } button:hover { background: #e9e9e9; } button.inline { font-size: 14px; } .star form { margin: 12px 0 0; } .panel { max-width: 480px; } .muted { color: #555; } @media (max-width: 600px) { body { padding: 0 16px 32px; } header { gap: 8px; min-height: 64px; } header nav { gap: 10px; } hr { margin-bottom: 32px; } .star { margin-bottom: 24px; } } `; // Only replaces a title the user hasn't written themselves. const shareScript = ` const { url, title, csrf } = document.forms.share.elements; let fetched = ""; const untouched = () => !title.value || title.value === fetched; const lookup = async () => { const link = url.value; if (!link || !url.checkValidity() || !untouched()) return; title.placeholder = "Fetching title…"; try { const res = await fetch("/xrpc/site.hackery.stars.getTitle", { method: "POST", headers: { "content-type": "application/json", "x-csrf-token": csrf.value }, body: JSON.stringify({ url: link }), }); if (!res.ok || url.value !== link || !untouched()) return; fetched = title.value = (await res.json()).title; } finally { title.placeholder = ""; } }; url.addEventListener("change", lookup); lookup(); `; const shareScriptHash = "sha256-" + new Uint8Array( await crypto.subtle.digest("SHA-256", new TextEncoder().encode(shareScript)), ).toBase64(); const ENTITIES: Record = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'", }; export const escape = (s: string) => s.replace(/[&<>"']/g, (c) => ENTITIES[c]); export const profilePath = (identifier: string) => `/${encodeURIComponent(identifier).replaceAll("%3A", ":")}`; export const profileUrl = (actor: Actor) => profilePath(actor.handleVerified === false ? actor.did : actor.handle); const avatar = (actor: Actor, large = false) => actor.avatar ? `` : "★"; const unverified = (actor: Actor) => actor.handleVerified === false ? ' (unverified)' : ""; const actorLink = (actor: Actor) => `${escape(actor.handle)}`; export const notice = (text: string | undefined) => text ? `

${escape(text)}

` : ""; export const list = (items: string[], empty: string, more?: string) => (items.join("") || `

${empty}

`) + (more ? `

Older stars →

` : ""); export function page( title: string, body: string, account?: { handle: string; url: string }, ): Response { const nav = account ? `${escape(account.handle)}` : 'Sign in'; return new Response( ` ${escape(title)} - Starboard
Starboard
${body}
`, { headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "x-content-type-options": "nosniff", "content-security-policy": `default-src 'none'; img-src https:; style-src 'unsafe-inline'; script-src '${shareScriptHash}'; connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'`, }, }, ); } export const browseForm = `
`; export const signInForm = `

Sign in

Sign in with your ATProto account.


`; // The hidden Share button comes first so that it's the default button for Enter. export function shareForm( csrf: string, fields: { url?: string; title?: string; comment?: string; notice?: string }, ): string { const url = escape(fields.url ?? ""); const title = escape(fields.title ?? ""); const comment = escape(fields.comment ?? ""); return `

Add Star

Share a link and a comment.


${notice(fields.notice)}
`; } export function followForm( csrf: string, actor: Actor, followed: boolean, ): string { return `
`; } export const welcome = `

Starboard

Links worth sharing, from people you follow.


${browseForm}
`; export function feed(csrf: string, body: string): string { return `

Starboard

Links shared by people you follow.


${browseForm}
${body}`; } export function profile(actor: Actor, follow: string, body: string): string { const handle = escape(actor.handle); const label = unverified(actor); return `

${avatar(actor, true)} ${handle}${label}

Links shared by ${handle}.


${follow && `
${follow}
`}${body}`; } export function star(entry: StarEntry, actor: Actor, csrf?: string): string { const { link } = entry; const when = escape(entry.star.createdAt); const date = new Date(entry.star.createdAt); const label = Number.isNaN(date.valueOf()) ? when : new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "long", year: "numeric", hour: "2-digit", minute: "2-digit", hour12: false, timeZone: "UTC", }).format(date); const domain = entry.star.title ? `${escape(new URL(link).hostname)}` : ""; const comment = entry.star.comment ? `

${escape(entry.star.comment)}

` : ""; const remove = csrf ? `
` : ""; const author = `${avatar(actor)} ${actorLink(actor)}${unverified(actor)}`; const href = escape(link); const title = escape(entry.star.title || link); return `
${title} ${domain} ${comment}
${author}
${remove}
`; }